SPB Git

spb/groupe-ka Public

Groupe KA — site du holding + KA ID (compte unique & SSO des 7 plateformes). Next.js 16, SQLite, Google & Apple login.

TypeScript 89.7% CSS 5.8% HTML 4.4%
6.7 KB · 190 lines tsx
Raw Blame History
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2// Profil public d'un membre (opt-in) — groupe-ka.com/u/{ka_id}.3// Affiche nom, photo, statut, bio, emploi, ville, âge, site et réseaux —4// JAMAIS le courriel ni le téléphone. 404 indistinct si privé/inexistant.5import type { Metadata } from "next";6import {7  db,8  parseSocials,9  ageFromBirthDate,10  type UserRow,11} from "@/lib/db";12import { roleCard } from "@/lib/roles";1314export const metadata: Metadata = {15  title: "Profil de membre | Groupe KA",16  description: "Profil public d'un membre du Groupe KA.",17};1819export const dynamic = "force-dynamic";2021function fmtDate(iso: string | null): string {22  if (!iso) return "—";23  const d = new Date(iso.includes("T") ? iso : iso.replace(" ", "T") + "Z");24  if (Number.isNaN(d.getTime())) return "—";25  return d26    .toLocaleDateString("fr-CA", { month: "long", year: "numeric" });27}2829const SOCIAL_LABELS: Record<string, string> = {30  instagram: "Instagram",31  facebook: "Facebook",32  x: "X",33  linkedin: "LinkedIn",34  tiktok: "TikTok",35  youtube: "YouTube",36};3738function socialHref(key: string, value: string): string {39  if (/^https?:\/\//i.test(value)) return value;40  const handle = value.replace(/^@/, "");41  const base: Record<string, string> = {42    instagram: "https://instagram.com/",43    facebook: "https://facebook.com/",44    x: "https://x.com/",45    linkedin: "https://www.linkedin.com/in/",46    tiktok: "https://www.tiktok.com/@",47    youtube: "https://www.youtube.com/",48  };49  return (base[key] ?? "https://") + handle;50}5152export default async function ProfilPublic({53  params,54}: {55  params: Promise<{ kaId: string }>;56}) {57  const { kaId } = await params;58  const clean = /^ka-\d{10}$/.test(kaId) ? kaId : null;59  const row = clean60    ? (db.prepare("SELECT * FROM users WHERE ka_id = ?").get(clean) as61        | UserRow62        | undefined)63    : undefined;6465  // privé ou inexistant : indistinguable, volontairement66  if (!row || !row.public) {67    return (68      <main className="mx-auto max-w-md px-4 pb-16 sm:px-6">69        <section className="pt-12 text-center sm:pt-14">70          <p className="kicker rise justify-center">Profil de membre</p>71          <h1 className="gk-display rise mt-4 text-[clamp(28px,5vw,40px)] leading-[1.02] font-bold tracking-[-0.035em] uppercase [animation-delay:0.08s]">72            Profil <span className="outline-txt">introuvable</span>73          </h1>74          <p className="rise mt-4 text-[14px] text-ink-2 [animation-delay:0.16s]">75            Ce profil n&apos;existe pas ou n&apos;est pas public.76          </p>77        </section>78      </main>79    );80  }8182  const socials = parseSocials(row.socials);83  const age = ageFromBirthDate(row.birth_date);84  const facts: [string, string][] = [85    ...(row.job_title86      ? ([["Emploi", row.company ? `${row.job_title} · ${row.company}` : row.job_title]] as [string, string][])87      : row.company88        ? ([["Entreprise", row.company]] as [string, string][])89        : []),90    ...(row.city ? ([["Ville", row.city]] as [string, string][]) : []),91    ...(age !== null ? ([["Âge", `${age} ans`]] as [string, string][]) : []),92    ["Membre depuis", fmtDate(row.created_at)],93    ["KA-ID", row.ka_id ?? "—"],94  ];9596  return (97    <main className="mx-auto max-w-md px-4 pb-16 sm:px-6">98      <section className="pt-10 text-center sm:pt-12">99        <p className="kicker rise justify-center">100          Profil de membre · Groupe KA101        </p>102103        <div className="rise mt-7 flex justify-center [animation-delay:0.08s]">104          {row.avatar_url ? (105            // eslint-disable-next-line @next/next/no-img-element106            <img107              src={row.avatar_url}108              alt={`Photo de ${row.name}`}109              width={176}110              height={176}111              referrerPolicy="no-referrer"112              className="h-44 w-44 rounded-full border-[3px] border-ink object-cover shadow-[8px_8px_0_rgba(20,24,20,0.18)]"113            />114          ) : (115            <span className="gk-display flex h-44 w-44 items-center justify-center rounded-full border-[3px] border-ink bg-lime text-[64px] font-bold shadow-[8px_8px_0_rgba(20,24,20,0.18)]">116              {row.name.charAt(0).toUpperCase()}117            </span>118          )}119        </div>120121        <h1 className="gk-display rise mt-6 text-[clamp(28px,6vw,42px)] leading-[1.02] font-bold tracking-[-0.03em] [animation-delay:0.14s]">122          {row.name}123        </h1>124        {roleCard(row.role) && (125          <p className="rise mt-3 [animation-delay:0.18s]">126            <span className="gk-mono inline-block -rotate-1 rounded-[5px] bg-ink px-3 py-[4px] text-[10.5px] font-bold tracking-[0.12em] text-lime">127              {roleCard(row.role)}128            </span>129          </p>130        )}131        {row.bio && (132          <p className="rise mx-auto mt-4 max-w-sm text-[14.5px] leading-relaxed text-ink-2 [animation-delay:0.22s]">133            {row.bio}134          </p>135        )}136137        <div className="gk-card rise mt-7 p-0 text-left [animation-delay:0.26s]">138          {facts.map(([label, value], i) => (139            <div140              key={label}141              className={`flex items-baseline justify-between gap-4 px-5 py-[13px] ${i < facts.length - 1 ? "border-b border-dashed border-[rgba(20,24,20,0.18)]" : ""}`}142            >143              <span className="klabel flex-none">{label}</span>144              <span145                className={`text-right text-[13.5px] font-semibold ${label === "KA-ID" ? "gk-mono text-green" : ""}`}146              >147                {value}148              </span>149            </div>150          ))}151        </div>152153        {(row.website || Object.keys(socials).length > 0) && (154          <div className="rise mt-5 flex flex-wrap justify-center gap-2 [animation-delay:0.32s]">155            {row.website && (156              <a157                href={row.website}158                target="_blank"159                rel="noopener noreferrer nofollow"160                className="stat-chip !text-[11.5px] hover:bg-lime-soft"161              >162                🌐 Site web163              </a>164            )}165            {Object.entries(socials).map(([k, v]) => (166              <a167                key={k}168                href={socialHref(k, v)}169                target="_blank"170                rel="noopener noreferrer nofollow"171                className="stat-chip !text-[11.5px] hover:bg-lime-soft"172              >173                {SOCIAL_LABELS[k] ?? k}174              </a>175            ))}176          </div>177        )}178179        <p className="mt-6 text-[12px] text-ink-2">180          Profil publié volontairement par ce membre —{" "}181          <a href={`/m/${row.ka_id}`} className="underline underline-offset-4">182            vérifier son KA-ID183          </a>184          . Courriel et téléphone jamais affichés.185        </p>186      </section>187    </main>188  );189}190